Skip to content

fix(schemas): freeze published schema bytes; the generator refuses to rewrite one (#1044 slice A) - #1116

Open
pengfei-threemoonslab wants to merge 2 commits into
mainfrom
claude/issue-1044-slice-a-schema-freeze
Open

pengfei-threemoonslab wants to merge 2 commits into
mainfrom
claude/issue-1044-slice-a-schema-freeze

Conversation

@pengfei-threemoonslab

Copy link
Copy Markdown
Contributor

Summary

Protected paths: yes (scripts/generate_schemas.py, the new scripts/published_schema_pins.json, the tests that pin published schemas: tests/test_effect_coverage.py, tests/test_human_review_request.py, the new tests/test_published_schema_freeze.py; and the two models that render the frozen docs/agent-handoff-schema.v9.json, docs/agent-result-schema.v3.json and docs/agent-boundary-result-schema.v3.json, whose bytes do not change) — needs owner review before merge.

Refs #1044 (slice A only; the issue stays open for slice B, the diff output schemas).

Must land before the v1.3.0 version bump. Until it does, the bump rewrites the three files below again, and after it the bump PR must run python scripts/generate_schemas.py --pin (the freeze test fails on that PR until it does; see Release-readiness notes).

The defect. version: str = __version__ in AgentHandoffTool and AgentResultTool put the package version into the JSON-schema default of tool.version, so every release-prep bump rewrote docs/agent-handoff-schema.v9.json, docs/agent-result-schema.v3.json and docs/agent-boundary-result-schema.v3.json under unchanged schema versions (#847, #907). That breaks docs/report-1-0-contract.md rule 3. The guard was two hand-written pin tables covering 33 of 177 tag-published files. Its completeness test compared one hand list with the other.

What changed.

  • Models (schemas/agent_handoff.py, schemas/agent_result_v1.py): version is Field(default_factory=_running_version, json_schema_extra={"default": "1.2.0"}). At runtime the default is still the running version. The schema keeps the "1.2.0" the files were published with. Generated output is byte-identical: --check is clean and _canonical_json sorts keys. A comment on each says a successor schema version must emit no release-specific default and how to drop it. AgentResultTool feeds both v3 files, so its comment says to give a successor its own tool model rather than drop the extra in place.
  • One freeze mechanism, in scripts/generate_schemas.py:
    • Published set. Every docs/*-schema*.json that a v<version> release tag carries, read from git (git ls-tree <tag> -- docs/), not from a hand list. Every pinned file counts too. While the package version has no tag (release preparation), every schema document on disk counts.
    • freeze_violations() reports a published file that is missing, has no pin, no longer matches its pin, or differs from the bytes the newest release carrying it published. --check runs it, and so does the test.
    • The generator refuses. When regenerating would change a published or pinned file, it writes nothing (not even unpublished files), exits 1, says the change needs a successor schema version, and names the locked pydantic as the other possible cause. --check reports the same refusal. Unpublished files keep today's --check behaviour.
    • --pin adds pins for published files that have none. It never moves an existing pin, needs tags, and refuses while anything drifts.
    • No tags (shallow clone, sdist): the published set falls back to the pin manifest instead of failing closed, and the generator prints a note. That loses nothing on a commit CI passed: CI's suite and test jobs (and both release-verify workflows) check out with fetch-tags: true (.github/workflows/ci.yml suite and test checkouts). There, the freeze test fails if a tag-published file has no pin, so the manifest names every published file. Failing closed would break the generator for every contributor without tags while protecting nothing more. The freeze tests need tags and fail, rather than skip, without them (PR fix(evidence): a declaration cannot discharge a category it does not cover (#409 follow-ups) #413 review 3).
  • scripts/published_schema_pins.json: 177 sha256 pins seeded from current bytes, plus a refrozen record of the two build(deps): refresh dev, build-backend and publish locks; uv 0.13.1, pydantic 2.14.1 #1088 files (below). All 25 _PUBLISHED_SCHEMA_SHA256 digests and 6 of 8 FROZEN_CONTROL_SCHEMAS digests carried over unchanged. The other two are explained in the next bullet.
  • Pin tables merged. tests/test_effect_coverage.py loses _PUBLISHED_SCHEMA_SHA256 and its two tests. It keeps the per-row marker test, and the rows must now be a subset of the manifest. tests/test_human_review_request.py's FROZEN_CONTROL_SCHEMAS becomes a list of names. The test hashes against the manifest and keeps its HumanControlAction grammar assertion.
  • Docs. docs/report-1-0-contract.md rule 3 (no longer names _PUBLISHED_SCHEMA_SHA256), STABILITY.md and CONTRIBUTING.md § Schema Changes. docs/release-runbook.md step 3 says the change that sets the version runs the generator, then --pin. One CHANGELOG.md line goes under ### Changes, after the empty-preflight entry.

Owner decision

1.3.0 will publish docs/agent-boundary-result-schema.v3.json and docs/verifier-schema.v0.21.json with bytes that differ from what 1.2.0 published. #1088 (pydantic 2.14 lock refresh) added "additionalProperties": false to CapabilityDiffRow in both, in place, after v1.2.0. CHANGELOG ## Unreleased records it. As the 2026-11-05 review recommends, this PR freezes them at their current main bytes and records that in the manifest's refrozen block (differs_from_release: v1.2.0). Without the record, freeze_violations reports exactly these two files as differing from v1.2.0 (test_bytes_that_differ_from_a_release_need_a_recorded_refreeze). A consumer that pinned the 1.2.0 bytes by sha256 sees a mismatch at 1.3.0 under unchanged schema versions.

Alternative: restore the 1.2.0 bytes. Check both files out from v1.2.0, add a generator post-process that deletes that one additionalProperties from CapabilityDiffRow in both, drop the two refrozen entries, and withdraw the #1088 CHANGELOG line. Costs:

  • A hand-maintained divergence from what the locked pydantic renders.
  • The published grammar is then looser than the producing model, which has always rejected an unknown row key.
  • Every later pydantic change near that def needs the post-process kept in step.

Benefit: digest pins taken on 1.2.0 keep matching.

Pydantic is a second way published bytes can drift. The runtime floor is pydantic>=2.12.5 and the dev lock is 2.14.3 (#1107, after #1088's 2.14.1). The dependency-floors job does not run generate_schemas.py, on purpose (its comment cites #1088). None of its listed test files runs the freeze tests. I ran the generator at the floor (pydantic 2.12.5) and at 2.13.4. It refuses to rewrite verifier-schema.v0.21.json and agent-boundary-result-schema.v3.json, writes nothing, exits 1, and names constraints/dev.txt. Before this PR it silently rewrote both published files. This machine's shared .venv has 2.13.4, so --check failed on main there for exactly this reason.

Type

  • Check or risk-model change
  • Input adapter change
  • CLI or GitHub Action behavior
  • Report, schema, or SARIF output
  • Documentation only

Authorship

  • A coding agent wrote some or all of this change. Agent: Claude Code (Claude Opus 5.5)
  • A person read the whole diff before opening this pull request.

Acceptance criteria

  • Regenerating schemas after a package version bump leaves agent-handoff v9, agent-result v3 and agent-boundary-result v3 byte-identical.
    • tests/test_published_schema_freeze.py::test_a_version_bump_leaves_every_schema_document_byte_identical runs every builder in a subprocess with agents_shipgate.__version__ = "9.9.9". It asserts that every *-schema* output equals this version's and that the generator would refuse nothing. It also checks that the bump took effect: both tool models default to 9.9.9, and determinism-boundary.json moved.
    • Scratch copy of the tree, __version__ = "1.3.0", no .git (so the pin fallback): write exits 0, --check exits 0, no docs/*-schema*.json differs from the worktree, and only determinism-boundary.{json,md} moved.
    • Fails without the fix: with the two model lines reverted to version: str = __version__, the bump test fails on the three files. In the scratch copy, write exits 1 with Refused: regenerating would change 3 published schema file(s). Nothing was written. and --check exits 1.
  • The generator refuses to overwrite a basename published at a release tag. It writes nothing, exits non-zero, and says the change needs a successor version.
    • test_the_generator_writes_nothing_when_a_published_file_would_change: v9 is changed the way a bump used to change it, next to the unpublished checks.json. Write mode exits 1, the output directory stays empty, the message names published at v1.2.0 and the successor route, and --check reports the same.
    • Positive control test_the_generator_writes_when_no_published_file_would_change: the same run without the change writes both files, and v9's bytes equal its pin.
    • No tags: test_without_release_tags_the_pins_still_refuse_a_rewrite.
  • The freeze set comes from the filesystem and the tags. A missing pin or changed bytes fails.
    • test_every_published_schema_document_keeps_its_bytes runs freeze_violations on this tree. test_the_published_set_is_every_schema_document_a_release_tag_carries checks that the tags carry at least 177 files, all on disk and all pinned.
    • Each guard's defect, on links into this tree: a changed published file gives changed + differs_from_release; a dropped pin gives unpinned; a deleted file gives missing; an empty refrozen reports exactly the two build(deps): refresh dev, build-backend and publish locks; uv 0.13.1, pydantic 2.14.1 #1088 files. While the version has no tag, every unpinned schema document must be pinned, including a new one.
    • On the real tree: "default": "1.3.0" in v9 makes --check exit 1 with both problems, and test_every_published_schema_document_keeps_its_bytes fails. Deleting the report-schema.v0.43.json pin makes --check exit 1 (has no pin … --pin). --pin then restores the manifest byte-identical, and test_pin_adds_the_missing_pin_and_never_moves_one asserts the same round trip.
  • A successor emits no release-specific default. test_no_unpublished_schema_carries_a_release_specific_default covers it. With _AGENT_RESULT_SUFFIX = "v4" (a successor cut without dropping the frozen default), it fails on {'agent-result-schema.v4.json': ['1.2.0']}.

Verification

Local runs used a scratch venv built from constraints/dev.txt with --require-hashes (pydantic 2.14.3) and PYTHONPATH=src. agents_shipgate.__file__ resolved to this worktree. The shared .venv has pydantic 2.13.4, older than the lock. The full suite was not run locally; CI's six shards are the gate.

Command Exit
python scripts/generate_schemas.py --check 0
python scripts/generate_schemas.py --pin 0 (Every published schema document is already pinned.)
python -m pytest tests/test_published_schema_freeze.py tests/test_effect_coverage.py tests/test_human_review_request.py tests/test_schema_roundtrip.py tests/test_determinism_boundary.py tests/test_report_1_0_contract.py tests/test_remediation_metadata.py tests/test_reports.py tests/test_public_surface_contract.py tests/test_distribution_surface_parity.py tests/test_shard_partition.py tests/test_release_pipeline.py tests/test_agent_handoff.py tests/test_agent_boundary.py tests/test_codex_boundary_check.py -n 4 0 (1617 passed, 1 skipped: the existing "source tree and newest release are both 1.2.0" skip in test_distribution_surface_parity.py)
the same freeze, roundtrip, effect-coverage, human-review and shard-partition files after the last edit 0
python -m ruff check . 0
Simulated bump to 1.3.0 in a scratch copy: write, then --check 0, 0
The same with the model fix reverted: write, then --check 1, 1
The generator under pydantic 2.12.5 (floor) and 2.13.4, write mode 1, 1 (refuses verifier 0.21 and agent-boundary-result v3; nothing written)
The bump test with the model fix reverted 1
The successor-default test with _AGENT_RESULT_SUFFIX = "v4" 1

tests/shard_seconds.json gains the new file's measured weight (17.1 s at -n 4) and a supplemental measurement record, as #1092 and #1105 did.

Release-readiness notes

  • No user-code import added to default scan paths

  • No network access added to default scan paths

  • New or changed check IDs are documented in docs/checks.md (none changed)

  • Report/schema changes are additive or documented in STABILITY.md (no schema bytes change; the freeze rule is documented)

  • Lands before the version bump. The bump PR must run python scripts/generate_schemas.py --pin, which pins every docs/*-schema*.json the tag will publish. Today that is the nine files new since 1.2.0 (host-grants inventory/baseline/drift v0.8 and v0.9, openshell-native evidence/trust v1, preflight v0.6), plus any this batch adds, such as slice B's. While __version__ has no tag, test_every_published_schema_document_keeps_its_bytes fails until they are pinned, so nothing reaches the tag unpinned.

  • Coordination. After this lands, a PR that edits a tag-published schema in place fails --check and the generator refuses it. A schema that is new since 1.2.0 can still change freely until the bump pins it.

  • Runtime behaviour does not change: tool.version in emitted handoff, agent-result, agent-boundary and codex-boundary artifacts is still the running version.

Out of scope

🤖 Generated with Claude Code

… rewrite one (#1044 slice A)

Three published schemas embedded the package version as a JSON-schema
default (`tool.version`), so every release-prep bump rewrote
docs/agent-handoff-schema.v9.json, docs/agent-result-schema.v3.json and
docs/agent-boundary-result-schema.v3.json under unchanged schema versions
(#847, #907), against docs/report-1-0-contract.md rule 3.

- AgentHandoffTool and AgentResultTool: `version` keeps the running version
  at runtime (default_factory) while the schema keeps the published
  "1.2.0" (json_schema_extra). Byte-identical output, proven under a
  simulated bump; a comment says successors emit no release-specific default.
- scripts/generate_schemas.py: one freeze mechanism. The published set is
  every docs/*-schema*.json a `v<version>` tag carries (from git, not a hand
  list) plus every pinned file, and, while the package version has no tag,
  every schema document on disk. When regenerating would change a published
  or pinned file, the generator writes nothing, exits 1 and names the
  successor-version route (and the locked-pydantic cause). `--check` also
  reports a published file that is missing, unpinned, changed, or different
  from the newest release that carried it. `--pin` adds missing pins and
  never moves one. Without tags (shallow clone, sdist) the published set
  falls back to the pin manifest, which CI keeps complete.
- scripts/published_schema_pins.json: 177 pins seeded from current bytes,
  the union of both former tables. agent-boundary-result v3 and verifier
  0.21 are recorded as re-frozen relative to v1.2.0 (#1088).
- tests: the two hand pin tables (test_effect_coverage
  _PUBLISHED_SCHEMA_SHA256, test_human_review_request FROZEN_CONTROL_SCHEMAS
  digests) now read the one manifest. The #561 and version-stamp exceptions
  are gone: both covered bytes no release shipped, and v1.0.0-v1.2.0
  published both files as now pinned. tests/test_published_schema_freeze.py
  runs the mechanism on this tree and shows each guard failing on its defect.
- Docs: report-1-0-contract rule 3, STABILITY, CONTRIBUTING, release-runbook
  step 3 (pin at the version bump), CHANGELOG.

Refs #1044
…fault

The test comment said v1.0.0 through v1.2.0 published agent-result v3
exactly as pinned; v1.0.0 and v1.1.0 each stamped its own version into
its `tool.version` default, so only v1.2.0 did. STABILITY.md,
docs/report-1-0-contract.md and the freeze test's docstring now say the
`1.2.0` default is the value v1.2.0 published, not the one these
documents were always published with.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant